##
# Copyright (C) 2014 Hai Bison
#
# See the file LICENSE at the root directory of this project for copying
# permission.
#

require 'ostruct'
require 'set'

##
# This module defines some constants, such as gem names, version code, version
# names... This module also contains base class `Model`.
#
# You start all things from here, via primary instance of Redis:
# `GaNuong.redis`. And, you end all things from here too.
#
# For example:
#
# * Start GaNuong:
#
#         require 'redis'
#         require 'ga_nuong'
#         GaNuong.redis = Redis.new :url => 'redis://:password@0.0.0.0:999/9'
#
# * End it:
#
#         GaNuong.redis.shutdown
#
# * **Author:** Hai Bison
#
module GaNuong

    GEM_NAME = "G\u00e0 N\u01b0\u1edbng"
    VERSION_CODE = 4
    VERSION_NAME = '0.0.4'
    RELEASE_DATE = Time.new(2014, 7, 11)

    class << self
        attr_accessor :redis
    end

    ##
    # The base model.
    #
    # Currently, the framework supports these data types:
    #
    # * If a value is a normal object, it will be put into DB as a string.
    # * If a value is a hash, it will be put into DB as a hash.
    # * If a value is an array, it will be put into DB as a list.
    # * If a value is a set, it will be put into DB as a set.
    # * Doesn't support Redis' sorted set yet.
    #
    # If you want to remove a variable (which actually remove its key), set its
    # value to `nil`.
    #
    class Model < OpenStruct

        # SETTINGS

        ##
        # Set to `true` to let the model auto assigns to itself new UID when
        # creating new instance. Default is `true`.
        #
        AUTO_ID = true

        ##
        # Default set size that Redis supports. You can change this for your
        # needs. However this value does *not* affect indexes (which use sorted
        # set).'''
        #
        SET_SIZE = 2**32 - 1

        ##
        # This is *not* maximum length of strings that you can store (Redis
        # allows up to 512 MiB).
        #
        # This is the maximum length of strings that can be *calculated* their
        # score. If a string length is larger than this value, then only the
        # first *x* characters are used to calculate the strings' score. Of
        # course *x* is the value of this flag.
        #
        # Be careful if you want to change this if you *already* had some
        # scores -- all old scores will be invalid with new value.
        #
        MAX_STR_LEN = 99

        ##
        # Sets to `true` to compares strings case sensitively. Default is `nil`.
        #
        STR_CASE_SENSITIVE = nil

        ##
        # Convenient value for maximum string's score.
        #
        MAX_STR_SCORE = Float::MAX

        # OPTIONAL SETTINGS

        ##
        # This is a base model containing key-value pairs. This constant defines
        # keys and data types of values. It should be a map of symbols to
        # classes. The symbols are the names of instance fields that you will
        # use to hold values. And the classes are data types. For example:
        #
        #     KEYS = { :name => String, :email => String, :cash => Fixnum }
        #
        # Later you can use them like:
        #
        #     self.name = "Hai Bison"
        #     self[:email] = "hb@haibison.com"
        #     self.cash = 9 # USD  :-)
        #
        # You're free to declare as much symbols as you want. When you call
        # `#put`, all of them will be put into DB.
        #
        # If you make a sub model derived from another model. You should extend
        # `KEYS` like this:
        #
        #     class Base < GaNuong::Model
        #         KEYS = { :date_created => Time, :date_modified => Time }
        #     end
        #
        #     class Language < Base
        #         KEYS = superclass::KEYS.merge({ :date_of_birth => Float,
        #                 :founder => String, :license => String })
        #     end
        #
        # # Notes
        #
        # * Don't use any underscore `_` as prefix for key names. It's reserved.
        # * If one of your keys holds a collection. Remember to define its
        #   members' data type in `::COLLECTIONS_TYPES`.
        #
        KEYS = {}

        ##
        # This is a map of keys (in `::KEYS`, which hold collections) to their
        # members' data type.
        #
        # Supported types: `Float`, `Fixnum`, `Bignum`, `Time`, `String`,
        # `TrueClass`, `FalseClass`.
        #
        # For example:
        #
        #     class Ruby < Language
        #         ##
        #         # Of course, this is not OOP, just an example.
        #         #
        #         # `:os` is an array of operating systems that the language
        #         # supports.
        #         #
        #         KEYS = superclass::KEYS.merge({ :os => Array })
        #
        #         ##
        #         # Because `:os` (in `::KEYS`) holds a collection, we define
        #         # its members' data type here.
        #         #
        #         COLLECTIONS_TYPES = superclass::COLLECTIONS_TYPES.merge({
        #                 :os => String })
        #     end
        #
        COLLECTIONS_TYPES = {}

        ##
        # Add all fields' *symbols* that you want them to be indexed to this
        # set. If you do, you might consider overriding function `::score` to
        # support your values.
        #
        INDEXES = Set.new

        ### CONSTANTS

        ##
        # The main separator for components of a DB key.
        #
        SEP = ':'

        S_ID = '_id'
        S_IDS = '_ids'
        S_INDEXES = '_indexes'

        ##
        # The serie name of this object in DB.
        #
        # You can override this constant and return the serie name. This value
        # will be used to prefix keys in DB. Default is the class name
        # (`::name`).
        #
        def self.serie_name
            name
        end # serie_name

        ##
        # Returns the current unique ID of the model. If `inc` is `true`,
        # increases the ID by `1` and returns new value.
        #
        def self.uid inc=true
            key = [serie_name, S_ID].join SEP
            inc ? GaNuong.redis.incr(key) : GaNuong.redis.get(key).to_i
        end # uid

        ##
        # Override this function to "score" your values. By default, this
        # function supports: `Float`, `Fixnum`, `Bignum`, `Time`, `String`,
        # `TrueClass`, `FalseClass`, and collections (`Hash`, `Set`, `Array`).
        #
        # You provide either a single value via `obj` or a map of variable names
        # pointing to their value via `kv`. If you provide `obj` then `kv` is
        # ignored.
        #
        # For strings, this function turns them to lower case, then calculates
        # their value by summing up all characters.
        #
        # For collections, their value is their size.
        #
        # For unsupported types, their value is `0`.
        #
        # # Returns
        #
        # * If `obj` is not `nil`, returns its score. If `kv` is not `nil`,
        #   returns a map of original variable names to their scores.
        # * If both parameters are provided, returns both results, respectively.
        # * If both parameters are not provided, returns `0`.
        #
        def self.score obj=nil, **kv
            ##
            # Calculates score of `value`.
            #
            _score = lambda do |value|
                cls = value.class

                return value.size if [Hash, Set, Array].include? cls
                return value.to_f if value.is_a? Time
                return value if value.is_a? Numeric
                return value ? 1 : 0 if [TrueClass, FalseClass].include? cls

                if value.is_a? String
                    # We assume all strings take the same _max_str_len length.
                    # So we append all strings with "virtual" characters to fit
                    # _max_str_len. Every "virtual" character has a value of 0.
                    #
                    # Note: be careful not to change value's content here. It's
                    # an object and its content can be changed inside a method.
                    value = value.downcase if ! self::STR_CASE_SENSITIVE

                    result = 0
                    (0..value.size-1).each do |i|
                        result += value.codepoints[i] * 10 ** (self::MAX_STR_LEN - i)
                    end

                    return result
                end # cls is String

                0
            end # _score

            kv.each { |k, v| kv[k] = _score.call v } if ! kv.empty?
            obj = _score.call(obj) if obj

            return obj, kv if obj and ! kv.empty?
            return obj if obj
            return kv if ! kv.empty?

            0
        end # score

        ##
        # Casts string `s` to instance value of class `cls`, then returns that
        # value.
        #
        # Supported classes: `String`, `Float`, `Fixnum`, `Bignum`, `Time`,
        # `TrueClass`, `FalseClass`.
        #
        def self.cast cls, s
            return s if cls == String
            return s.to_f if cls == Float
            return s.to_i if [Fixnum, Bignum].include? cls
            return Time.at s.to_f if cls == Time
            return s.to_i.zero? ? false : true \
                    if [TrueClass, FalseClass].include? cls
        end # cast

        ##
        # Determines Ruby type of symbol `sym`, then reads data from Redis at
        # `db_key`, converts the data read to corresponding Ruby value and
        # returns that value.
        #
        def self.redis2ruby sym, db_key
            cls = self::KEYS[sym]
            member_type = self::COLLECTIONS_TYPES[sym]

            redis_value = ruby_value = nil

            if cls == Hash
                # This will be a hash
                redis_value = GaNuong.redis.hgetall db_key
                ruby_value = {}
                redis_value.each_pair do |key, value|
                    ruby_value[key.to_sym] = cast member_type, value
                end
            elsif cls == Set
                # This will be an array
                redis_value = GaNuong.redis.smembers db_key
                ruby_value = Set.new
                redis_value.each { |v| ruby_value << cast(member_type, v) }
            elsif cls == Array
                # `#lrange` will return an array
                ruby_value = GaNuong.redis.lrange(db_key, 0,
                        self::SET_SIZE).map! do |v|
                    v = cast member_type, v
                end
            elsif cls == Time
                ruby_value = Time.at GaNuong.redis.get(db_key).to_f
            elsif cls == Float
                ruby_value = GaNuong.redis.get(db_key).to_f
            elsif [Fixnum, Bignum].include? cls
                ruby_value = Integer GaNuong.redis.get db_key
            elsif [TrueClass, FalseClass].include? cls
                ruby_value = GaNuong.redis.get(db_key).to_i.zero? ? false : true
            else
                ruby_value = GaNuong.redis.get db_key
            end

            ruby_value
        end # redis2ruby

        ##
        # Adds an ID to ID set. This does nothing if `::AUTO_ID` is `false`, or
        # `_id` < 0.
        #
        def self.add_id _id
            return if _id < 0

            i = 0
            loop do
                key = [serie_name, S_IDS, i].join SEP
                if GaNuong.redis.scard(key) < self::SET_SIZE
                    GaNuong.redis.sadd key, _id
                    return
                end

                # TODO break this loop?
                i += 1
            end # loop
        end # add_id

        ##
        # Remove an ID from ID set. This does nothing if `::AUTO_ID` is `false`,
        # or `_id` < 0.
        #
        def self.remove_id _id
            return if _id < 0

            i = 0
            loop do
                key = [serie_name, S_IDS, i].join SEP
                break if GaNuong.redis.scard(key).zero?

                if GaNuong.redis.sismember key, _id
                    GaNuong.redis.srem key, _id
                    return
                end

                i += 1
            end # loop
        end # remove_id

        ##
        # Counts total items of this model.
        #
        def self.count
            result = i = 0

            loop do
                key = [serie_name, S_IDS, i].join SEP
                size = GaNuong.redis.scard key
                if size > 0
                    result += size
                    i += 1
                else
                    break
                end
            end # loop

            result
        end # count

        ##
        # Queries.
        #
        # # Parameters
        #
        # * `symbols`: array of symbols that you want to get their values. The
        #    values found will be attached to an `OpenStruct` object, that
        #    object will be passed back to `&block` for you to process.
        #
        #    If not provided, the *real* record will be passed to `&block`.
        #
        # * `id_gt`: If set then only looks for items having ID greater than the
        #    given value.
        #
        # * `id_lt`: If set then only looks for items having ID lower than the
        #    given value.
        #
        # * `limit`: To limits the results. `nil` is unlimited.
        #
        # * `order`: This is a map of 3, 4 or 6 paris. Available options are:
        #
        #   - `symbol` (required), is the symbol you want to order.
        #   - `min` (required), is the minimum score.
        #   - `max` (required), is the maximum score.
        #   - `ascending` (optional), default is `true`, which will order the
        #     result from low to high scores. Set to `false` or `nil` to use
        #     descending order.
        #   - `offset` (optional), is the offset. If set, `count` must be set
        #     too.
        #   - `count` (optional), is the count.
        #
        # * `&block`: The callback function which accepts one argument -- which
        #    is an `OpenStruct` object specified by `symbols`. You return `true`
        #    to accept the result; return `nil` or `false` otherwise.
        #
        #    If not provided, all items will be returned.
        #
        # # Returns
        #
        # An array of "accepted objects" which are specified by `&block` and
        # `symbols`.
        #
        # # Notes
        #
        # * If you use both `id_gt` and `id_lt`, they should not be conflicted.
        #   For example this is wrong: `id_gt=99` and `id_lt=9`. In such case,
        #   an empty array is returned.
        #
        # If the model does not support auto ID, an empty array is returned.
        #
        def self.query symbols=nil, id_gt=nil, id_lt=nil, limit=nil, **order,
                &block
            if ! self::AUTO_ID or (id_gt and id_lt and id_gt >= id_lt)
                return []
            end

            # BUILD THE KEY PREFIX

            prefix = serie_name

            # QUERY

            result = []
            id_set = 0
            loop do
                if order.empty?
                    key_ids = [serie_name, S_IDS, id_set].join SEP
                    id_set += 1
                    return result if GaNuong.redis.scard(key_ids).zero?
                    ids = GaNuong.redis.smembers key_ids
                else
                    key_ids = [serie_name, S_INDEXES, order[:symbol]].join SEP
                    return result if GaNuong.redis.zcard(key_ids).zero?

                    offset = order[:offset]
                    count = order[:count]

                    if offset and count
                        options = { :limit => [offset, count] }
                    else
                        options = {}
                    end

                    if order[:ascending]
                        ids = GaNuong.redis.zrangebyscore key_ids,
                                order[:min], order[:max], options
                    else
                        ids = GaNuong.redis.zrevrangebyscore key_ids,
                                order[:max], order[:min], options
                    end
                end # order is not empty

                ids.each do |id|
                    id = id.to_i
                    next if id_gt and id <= id_gt
                    next if id_lt and id >= id_lt

                    if symbols
                        obj = OpenStruct.new
                        db_key = [prefix, id, ''].join SEP
                        symbols.each do |sym|
                            obj[sym] = redis2ruby sym, db_key + sym.to_s
                        end

                        if block_given?
                            result << new(_id: id) if yield obj
                        else
                            result << obj
                        end

                        return result if limit and result.size >= limit
                    else
                        obj = new _id: id
                        if block_given?
                            result << obj if yield obj
                        else
                            result << obj
                        end
                    end
                end # for each id

                # Loop is for ID set only. For indexes we just need this loop
                # once.
                break if ! order.empty?
            end # loop

            result
        end # query

        ##
        # This works the same as `OpenStruct.new`. However, you can provide
        # options directly into `hash`. Options are names with prefix `__`.
        #
        # If you provide `_id`, this will try to get data from DB with that ID.
        # If not, ID will be generated and assigned to this instance only after
        # you called `#put`.
        #
        # ### Options
        #
        # * `__strict` (default `true`): If you provide `_id`, that means you
        #   are trying to get the item with an ID. If *all* of its keys are not
        #   available, `KeyError` will be raised, *unless* `__strict` is
        #   `false`.
        #
        def initialize hash=nil
            super hash

            # Options
            self.__strict = true if ! hash or ! hash.key? :__strict

            # Check ID
            if self._id
                # Try to get the item in DB
                name_count = 0
                self.class::KEYS.each_pair do |sym, cls|
                    db_key = [self.class.serie_name, self._id, sym].join SEP
                    if GaNuong.redis.exists db_key
                        self[sym] = self.class.redis2ruby sym, db_key
                        name_count += 1
                    else
                        self[sym] = nil
                    end
                end # KEYS.each_pair

                if self.__strict and ! self.class::KEYS.empty? and name_count.zero?
                    raise KeyError.new "ID not found: '#{self._id}'"
                end
            end # caller attached an ID
        end # initialize

        ##
        # Put this item into DB.
        #
        def put
            # NOTE: Don't call Redis' methods inside a pipeline, it's
            # `Redis::Future` and will raise error.

            key = self.class.serie_name + SEP
            if self.class::AUTO_ID
                self._id = self.class.uid if ! self._id or self._id < 0
                key << "#{self._id}#{SEP}"
                key_indexes = [self.class.serie_name, S_INDEXES, ''].join SEP
            end

            GaNuong.redis.pipelined do
                self.class::KEYS.each_pair do |sym, cls|
                    db_key = key + sym.to_s
                    value = self[sym]

                    if self.class::AUTO_ID and self.class::INDEXES.include? sym
                        key_index = key_indexes + sym.to_s
                        if value.nil?
                            GaNuong.redis.zrem key_index, self._id
                        else
                            GaNuong.redis.zadd key_index,
                                    self.class.score(value), self._id
                        end
                    end # if

                    if value.nil?
                        GaNuong.redis.del db_key
                        next
                    end

                    if value.is_a? Hash
                        GaNuong.redis.hmset db_key, value
                    elsif value.is_a? Set
                        if value.empty?
                            GaNuong.redis.del db_key
                        else
                            GaNuong.redis.sadd db_key, value.to_a
                        end
                    elsif value.is_a? Array
                        GaNuong.redis.del db_key
                        GaNuong.redis.rpush db_key, value
                    elsif value.is_a? Time
                        GaNuong.redis.set db_key, value.to_f
                    else
                        # This includes `Fixnum`, `Bignum`, `Float`, `String`...
                        GaNuong.redis.set db_key, value
                    end
                end # KEYS.each_pair
            end # pipelined

            self.class.add_id self._id if self.class::AUTO_ID
        end # put

        ##
        # Deletes this item.
        #
        # # Returns
        #
        # `true` if OK, `nil` otherwise.
        #
        def delete
            keys = []
            keys_indexes = nil
            if self.class::AUTO_ID and self._id and self._id >= 0 \
                    and ! self.class::INDEXES.empty?
                keys_indexes = []
            end

            self.class::KEYS.each_pair do |sym, cls|
                if self._id and self._id >= 0
                    key = [self.class.serie_name, self._id, sym].join SEP
                    if keys_indexes and self.class::INDEXES.include? sym
                        keys_indexes << sym.to_s
                    end
                else
                    key = [self.class.serie_name, sym].join SEP
                end
                keys << key
            end # KEYS.each_pair

            # Delete all keys
            GaNuong.redis.del keys if ! keys.empty?

            # Delete all key indexes
            if keys_indexes and ! key_indexes.empty?
                key = [self.class.serie_name, S_INDEXES, ''].join SEP
                keys_indexes.each do |v|
                    GaNuong.redis.zrem "#{key}#{v}", self._id
                end
            end # if

            remove_id self._id
        end # delete

    end # Model

end # GaNuong
